Micron Document
🎖️GitЯра🎖️

Commit bf929c20f274232e0eb8ddc19b2dc82403e99e5b


Parents : 005ad1a
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-05T19:15:56-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-06T00:15:56Z

feat(discovery): Mesh Beacon client with iOS 014-mesh-beacons parity (#6097)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

Changes

27 files changed, 1287 insertions(+), 74 deletions(-)


Diff

diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 87e70bf4fc..2adc314aaf 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -578,6 +578,7 @@ firmware
firmware_edition
firmware_old
firmware_recovery_banner
+firmware_recovery_ble_failed
firmware_recovery_button
firmware_recovery_dismiss
firmware_recovery_explanation
@@ -892,17 +893,43 @@ match_all
match_any
max
### MESH ###
+mesh_beacon
+mesh_beacon_broadcast
+mesh_beacon_broadcast_summary
+mesh_beacon_channels_title
+mesh_beacon_interval
+mesh_beacon_interval_error
mesh_beacon_invitations_title
+mesh_beacon_listen
+mesh_beacon_listen_summary
+mesh_beacon_message
+mesh_beacon_message_error
mesh_beacon_notification_body
mesh_beacon_notification_title
+mesh_beacon_offer_add
mesh_beacon_offer_channel
+mesh_beacon_offer_channel_key
+mesh_beacon_offer_channel_name
mesh_beacon_offer_discover
mesh_beacon_offer_dismiss
mesh_beacon_offer_from_unknown
mesh_beacon_offer_join
mesh_beacon_offer_preset
+mesh_beacon_offer_preset_setting
mesh_beacon_offer_region
+mesh_beacon_offer_region_setting
+mesh_beacon_offer_signal
mesh_beacon_offer_title
+mesh_beacon_on_preset
+mesh_beacon_on_region
+mesh_beacon_preset_indicator
+mesh_beacon_preset_none
+mesh_beacon_send_as_node
+mesh_beacon_target
+mesh_beacon_target_add
+mesh_beacon_target_channel_index
+mesh_beacon_target_remove
+mesh_beacon_targets
mesh_map_location
mesh_map_location_description
### MESHTASTIC ###

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
index d9d207056f..5037b00de1 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
@@ -206,7 +206,7 @@ class MeshDataHandlerImpl(
}
PortNum.MESH_BEACON_APP -> {
- handleMeshBeacon(packet)
+ handleMeshBeacon(packet, myNodeNum)
}
else -> {}
@@ -224,12 +224,16 @@ class MeshDataHandlerImpl(
* low-priority notification when the invitation is first seen (not on every periodic re-broadcast). Only beacons
* carrying a join offer (a channel) are actionable; message-only beacons are ignored.
*/
- private fun handleMeshBeacon(packet: MeshPacket) {
+ @Suppress("ReturnCount")
+ private fun handleMeshBeacon(packet: MeshPacket, myNodeNum: Int) {
+ // Ignore our own beacons (spec FR-001) — once broadcast is enabled a node that also listens would self-notify.
+ if (packet.from == myNodeNum) return
val payload = packet.decoded?.payload ?: return
val beacon = MeshBeacon.ADAPTER.decodeOrNull(payload, Logger)
// Only actionable beacons (carrying a channel offer) that we haven't already seen warrant a notification.
if (beacon?.offer_channel == null) return
- val offer = MeshBeaconOffer(fromNodeNum = packet.from, beacon = beacon)
+ val offer =
+ MeshBeaconOffer(fromNodeNum = packet.from, beacon = beacon, snr = packet.rx_snr, rssi = packet.rx_rssi)
if (meshBeaconRepository.add(offer)) {
scope.launch {
notificationManager.dispatch(

diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
index 5da583d763..eeb13d0a54 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
@@ -28,6 +28,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
@@ -40,6 +41,7 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.repository.AdminPacketHandler
+import org.meshtastic.core.repository.MeshBeaconPrefs
import org.meshtastic.core.repository.MeshBeaconRepository
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MessageFilter
@@ -91,7 +93,6 @@ class MeshDataHandlerTest {
private val storeForwardHandler: StoreForwardPacketHandler = mock(MockMode.autofill)
private val telemetryHandler: TelemetryPacketHandler = mock(MockMode.autofill)
private val adminPacketHandler: AdminPacketHandler = mock(MockMode.autofill)
- private val meshBeaconRepository = MeshBeaconRepository()
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
@@ -101,6 +102,18 @@ class MeshDataHandlerTest {
// still drives it; cancelled in tearDown.
private val geofenceScope = CoroutineScope(testDispatcher)
+ // Real repository over an in-memory prefs fake — its persistence write-through is exercised without a DataStore.
+ private val fakeBeaconPrefs =
+ object : MeshBeaconPrefs {
+ private val flow = MutableStateFlow<List<String>>(emptyList())
+ override val storedBeacons: StateFlow<List<String>> = flow
+
+ override fun setStoredBeacons(records: List<String>) {
+ flow.value = records
+ }
+ }
+ private val meshBeaconRepository = MeshBeaconRepository(fakeBeaconPrefs, geofenceScope)
+
@AfterTest
fun tearDown() {
geofenceScope.cancel()
@@ -416,6 +429,23 @@ class MeshDataHandlerTest {
assertEquals(0, meshBeaconRepository.offers.value.size)
}
+ @Test
+ fun `our own mesh beacon is ignored`() {
+ // Spec FR-001: ignore beacons from the scanning node itself (else a listen+broadcast node self-notifies).
+ val beacon = MeshBeacon(message = "Join us", offer_channel = ChannelSettings(name = "PartyNet"))
+ val packet =
+ MeshPacket(
+ from = 123, // == myNodeNum below
+ decoded = Data(portnum = PortNum.MESH_BEACON_APP, payload = beacon.encode().toByteString()),
+ )
+ every { dataMapper.toDataPacket(packet) } returns
+ DataPacket(from = "!self", bytes = beacon.encode().toByteString(), dataType = PortNum.MESH_BEACON_APP.value)
+
+ handler.handleReceivedData(packet, 123)
+
+ assertEquals(0, meshBeaconRepository.offers.value.size)
+ }
+
// --- Store-and-Forward handling ---
@Test

diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/ModuleConfigDataSource.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/ModuleConfigDataSource.kt
index 09b18f2a47..4f9778bcff 100644
--- a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/ModuleConfigDataSource.kt
+++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/ModuleConfigDataSource.kt
@@ -81,6 +81,8 @@ class ModuleConfigDataSource(
config.tak != null -> current.copy(tak = config.tak)
+ config.mesh_beacon != null -> current.copy(mesh_beacon = config.mesh_beacon)
+
else -> current
}
}

diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
index 874e38b998..ad0a60d3d1 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
@@ -81,6 +81,13 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
*/
val supportsLockdown = atLeast(V2_8_0)
+ /**
+ * Support for the Mesh Beacon module (`ModuleConfig.MeshBeaconConfig` broadcast/listen). The proto is upstream but
+ * the firmware module traces to a community fork; gate the config editor to 2.8.0+ so older radios don't show a
+ * config they'd silently ignore.
+ */
+ val supportsMeshBeacon = atLeast(V2_8_0)
+
companion object {
private val V2_6_8 = DeviceVersion("2.6.8")
private val V2_6_9 = DeviceVersion("2.6.9")

diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt
index 15fc21582c..6ae7046fff 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt
@@ -16,6 +16,8 @@
*/
package org.meshtastic.core.model
+import okio.ByteString.Companion.decodeBase64
+import okio.ByteString.Companion.toByteString
import org.meshtastic.proto.MeshBeacon
/**
@@ -25,8 +27,10 @@ import org.meshtastic.proto.MeshBeacon
*
* @param fromNodeNum The node that broadcast the beacon (informational only — beacons are unsigned).
* @param beacon The decoded advertisement, carrying the display [message][MeshBeacon.message] and the join offer.
+ * @param snr Signal-to-noise ratio of the received beacon packet, in dB (0 when unknown).
+ * @param rssi Received signal strength of the beacon packet, in dBm (0 when unknown).
*/
-data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon) {
+data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon, val snr: Float = 0f, val rssi: Int = 0) {
/** Stable identity for dedup/dismiss: a given sender advertising a given channel is one standing invitation. */
val key: String
get() = "$fromNodeNum:${beacon.offer_channel?.name.orEmpty()}"
@@ -36,4 +40,32 @@ data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon) {
val channelName: String?
get() = beacon.offer_channel?.name?.ifBlank { null }
+
+ /**
+ * Serializes to a single-line record `fromNodeNum|snr|rssi|<base64 MeshBeacon>` for lightweight prefs persistence.
+ * The base64 alphabet never contains `|`, so the delimiter is unambiguous.
+ */
+ fun encode(): String {
+ val beaconB64 = MeshBeacon.ADAPTER.encode(beacon).toByteString().base64()
+ return "$fromNodeNum|$snr|$rssi|$beaconB64"
+ }
+
+ companion object {
+ private const val RECORD_FIELD_COUNT = 4
+
+ /**
+ * Inverse of [encode]; returns null for a structurally malformed record (wrong field count, unparseable node
+ * number, or an undecodable beacon payload). An unparseable snr/rssi falls back to 0 — they are non-critical
+ * display metrics, not identity, so a bad numeric there does not discard an otherwise-valid invitation.
+ */
+ @Suppress("ReturnCount")
+ fun decode(record: String): MeshBeaconOffer? {
+ val parts = record.split('|', limit = RECORD_FIELD_COUNT)
+ if (parts.size != RECORD_FIELD_COUNT) return null
+ val node = parts[0].toIntOrNull() ?: return null
+ val beaconBytes = parts.last().decodeBase64()?.toByteArray() ?: return null
+ val beacon = runCatching { MeshBeacon.ADAPTER.decode(beaconBytes) }.getOrNull() ?: return null
+ return MeshBeaconOffer(node, beacon, parts[1].toFloatOrNull() ?: 0f, parts[2].toIntOrNull() ?: 0)
+ }
+ }
}

diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt
index c361009c72..e4d51f970f 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt
@@ -22,9 +22,14 @@ import okio.ByteString.Companion.decodeBase64
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.model.Channel
+import org.meshtastic.core.model.channelNum
+import org.meshtastic.core.model.numChannels
import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config.LoRaConfig
+import org.meshtastic.proto.Config.LoRaConfig.RegionCode
import org.meshtastic.proto.MeshBeacon
+import org.meshtastic.proto.ModuleSettings
/**
* Return a [ChannelSet] that represents the ChannelSet encoded by the URL.
@@ -79,19 +84,94 @@ val ChannelSet.primaryChannel: Channel?
fun ChannelSet.hasLoraConfig(): Boolean = lora_config != null
+/** How a received Mesh Beacon invitation can be joined, given the connected radio's current settings. */
+enum class BeaconJoinOption {
+ /** Add the offered channel to a free secondary slot with no retune/reboot (mesh shares the radio's frequency). */
+ ADD,
+
+ /** Retune the radio's primary channel (and LoRa preset/region) onto the offered mesh — reboots the radio. */
+ SWITCH,
+
+ /** The beacon carries no join offer (message-only) — nothing to join. */
+ NONE,
+}
+
+/**
+ * Decides whether a beacon can be joined by simply **adding** its channel (no reboot) or requires a **switch**
+ * (retune + reboot). Adding works only when the offered mesh sits on the radio's *current* frequency slot — Meshtastic
+ * secondary channels ride the primary channel's frequency, so the offered channel must resolve (name-hash) to the same
+ * slot the radio's primary is on, under a matching preset and region. Mirrors the Apple `014-mesh-beacons`
+ * FR-016/FR-017 logic.
+ *
+ * @param currentLora The radio's current [LoRaConfig] (`null` → can't reason, so [SWITCH]).
+ * @param currentChannels The radio's current channel settings, index 0 = primary.
+ */
+@Suppress("ReturnCount")
+fun MeshBeacon.beaconJoinOption(currentLora: LoRaConfig?, currentChannels: List<ChannelSettings>): BeaconJoinOption {
+ val offer = offer_channel ?: return BeaconJoinOption.NONE
+ val lora = currentLora ?: return BeaconJoinOption.SWITCH
+ // An offered preset must match the radio's; an omitted preset (null) means "ride the current preset" → matches.
+ val presetMatches = offer_preset == null || (lora.use_preset && offer_preset == lora.modem_preset)
+ // offer_region == UNSET (0) means "not offered"; only a set, differing region forces a switch.
+ val regionMatches = offer_region == RegionCode.UNSET || offer_region == lora.region
+ if (!presetMatches || !regionMatches) return BeaconJoinOption.SWITCH
+ // With an explicit slot override we can't compare the offered mesh's slot; be safe and switch.
+ if (lora.channel_num != 0 || lora.numChannels <= 0) return BeaconJoinOption.SWITCH
+ // Hash the *effective* names: an empty channel name resolves to its preset display name ("LongFast", …), which is
+ // what firmware hashes for the slot — comparing raw "" on both sides would misclassify an unnamed primary.
+ val currentSlot = lora.channelNum(Channel(currentChannels.firstOrNull() ?: ChannelSettings(), lora).name)
+ val offeredSlot = lora.channelNum(Channel(offer, lora).name)
+ return if (offeredSlot == currentSlot) BeaconJoinOption.ADD else BeaconJoinOption.SWITCH
+}
+
/**
- * Converts a received [MeshBeacon] join offer into a [ChannelSet], so it can be routed through the existing QR-code
- * channel-import flow ([org.meshtastic.core.ui.qr.ScannedQrCodeViewModel]). Returns null when the beacon carries no
- * join offer (an ambient message-only beacon).
+ * Builds the [ChannelSet] to hand the QR channel-import dialog for a given [option].
+ *
+ * [ADD][BeaconJoinOption.ADD] omits `lora_config` so the dialog merges the offered channel into a free secondary slot
+ * with no reboot. [SWITCH][BeaconJoinOption.SWITCH] carries a `lora_config` derived from [currentLora] with only the
+ * advertised preset+region applied and `channel_num` reset to 0 (firmware derives the frequency from the offered
+ * channel name — Apple FR-006). Every other radio setting (`hop_limit`, `tx_power`, `tx_enabled`, …) is preserved from
+ * [currentLora], so joining never silently zeroes them — the beacon proto advertises no such fields. Returns `null` for
+ * [NONE][BeaconJoinOption.NONE] or a beacon with no offered channel.
+ *
+ * Both paths strip position sharing from the offered channel ([withoutPositionSharing]) so joining a stranger's mesh
+ * never leaks our location — matching Apple's `joinBeaconMesh`/`addBeaconChannel`.
+ *
+ * @param currentLora The radio's current [LoRaConfig]; a switch alters only preset/region/channel_num.
*/
-fun MeshBeacon.toChannelSet(): ChannelSet? {
- val offerChannel = offer_channel ?: return null
- // offer_region always has a value (proto default), but it's only meaningful paired with an explicit preset —
- // without a preset we'd otherwise ship a LoRaConfig with use_preset=false and no custom radio fields set.
- val loraConfig = offer_preset?.let { LoRaConfig(use_preset = true, modem_preset = it, region = offer_region) }
- return ChannelSet(settings = listOf(offerChannel), lora_config = loraConfig)
+fun MeshBeacon.toJoinChannelSet(option: BeaconJoinOption, currentLora: LoRaConfig?): ChannelSet? {
+ val offerChannel = (offer_channel ?: return null).withoutPositionSharing()
+ return when (option) {
+ BeaconJoinOption.ADD -> ChannelSet(settings = listOf(offerChannel))
+
+ BeaconJoinOption.SWITCH -> {
+ // Always ship a LoRaConfig so channel_num resets to 0 and firmware re-derives the frequency from the
+ // offered
+ // channel name (Apple FR-006) — even when no preset is offered (else an explicit channel_num override would
+ // strand the radio on the old slot). Preserve every current field; override only the advertised
+ // preset/region.
+ val base = currentLora ?: LoRaConfig()
+ val loraConfig =
+ base.copy(
+ use_preset = true,
+ channel_num = 0,
+ modem_preset = offer_preset ?: base.modem_preset,
+ region = if (offer_region != RegionCode.UNSET) offer_region else base.region,
+ )
+ ChannelSet(settings = listOf(offerChannel), lora_config = loraConfig)
+ }
+
+ BeaconJoinOption.NONE -> null
+ }
}
+/**
+ * Zeroes `position_precision` on the offered channel so joining a beaconed mesh never broadcasts our location to a mesh
+ * of strangers (privacy-first; Apple sets `positionPrecision = 0` on both add and switch).
+ */
+private fun ChannelSettings.withoutPositionSharing(): ChannelSettings =
+ copy(module_settings = (module_settings ?: ModuleSettings()).copy(position_precision = 0))
+
/**
* Return a URL that represents the [ChannelSet]
*

diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshBeaconOfferTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshBeaconOfferTest.kt
new file mode 100644
index 0000000000..7682492567
--- /dev/null
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshBeaconOfferTest.kt
@@ -0,0 +1,196 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.model.util
+
+import okio.ByteString.Companion.encodeUtf8
+import org.meshtastic.core.model.MeshBeaconOffer
+import org.meshtastic.proto.ChannelSettings
+import org.meshtastic.proto.Config.LoRaConfig
+import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
+import org.meshtastic.proto.Config.LoRaConfig.RegionCode
+import org.meshtastic.proto.MeshBeacon
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+class MeshBeaconOfferTest {
+
+ private val radioLora =
+ LoRaConfig(use_preset = true, modem_preset = ModemPreset.LONG_FAST, region = RegionCode.US, channel_num = 0)
+ private val radioChannels = listOf(ChannelSettings(name = "HomeMesh"))
+
+ @Test
+ fun `no offer channel yields NONE`() {
+ val beacon = MeshBeacon(message = "hi")
+ assertEquals(BeaconJoinOption.NONE, beacon.beaconJoinOption(radioLora, radioChannels))
+ }
+
+ @Test
+ fun `matching preset region and slot yields ADD`() {
+ // Offering the radio's own primary channel name forces an identical name-hash slot → addable with no reboot.
+ val beacon =
+ MeshBeacon(
+ offer_channel = ChannelSettings(name = "HomeMesh"),
+ offer_preset = ModemPreset.LONG_FAST,
+ offer_region = RegionCode.US,
+ )
+ assertEquals(BeaconJoinOption.ADD, beacon.beaconJoinOption(radioLora, radioChannels))
+ }
+
+ @Test
+ fun `unnamed primary matches a beacon that names the preset explicitly yielding ADD`() {
+ // The radio's primary has an empty name (resolves to the preset display name "LongFast" for the slot hash); a
+ // beacon offering a channel literally named "LongFast" on the same preset+region is the same slot -> ADD.
+ // Without effective-name resolution the empty primary would hash "" and misclassify as SWITCH.
+ val emptyPrimary = listOf(ChannelSettings(name = ""))
+ val beacon =
+ MeshBeacon(
+ offer_channel = ChannelSettings(name = "LongFast"),
+ offer_preset = ModemPreset.LONG_FAST,
+ offer_region = RegionCode.US,
+ )
+ assertEquals(BeaconJoinOption.ADD, beacon.beaconJoinOption(radioLora, emptyPrimary))
+ }
+
+ @Test
+ fun `different preset forces SWITCH`() {
+ val beacon =
+ MeshBeacon(
+ offer_channel = ChannelSettings(name = "HomeMesh"),
+ offer_preset = ModemPreset.SHORT_FAST,
+ offer_region = RegionCode.US,
+ )
+ assertEquals(BeaconJoinOption.SWITCH, beacon.beaconJoinOption(radioLora, radioChannels))
+ }
+
+ @Test
+ fun `different region forces SWITCH`() {
+ val beacon =
+ MeshBeacon(
+ offer_channel = ChannelSettings(name = "HomeMesh"),
+ offer_preset = ModemPreset.LONG_FAST,
+ offer_region = RegionCode.EU_868,
+ )
+ assertEquals(BeaconJoinOption.SWITCH, beacon.beaconJoinOption(radioLora, radioChannels))
+ }
+
+ @Test
+ fun `null lora config forces SWITCH`() {
+ val beacon = MeshBeacon(offer_channel = ChannelSettings(name = "HomeMesh"))
+ assertEquals(
+ BeaconJoinOption.SWITCH,
+ beacon.beaconJoinOption(currentLora = null, currentChannels = emptyList()),
+ )
+ }
+
+ @Test
+ fun `toJoinChannelSet omits lora for ADD and includes it for SWITCH`() {
+ val beacon =
+ MeshBeacon(
+ offer_channel = ChannelSettings(name = "PartyNet"),
+ offer_preset = ModemPreset.LONG_FAST,
+ offer_region = RegionCode.US,
+ )
+ assertNull(beacon.toJoinChannelSet(BeaconJoinOption.ADD, radioLora)?.lora_config, "ADD must not retune")
+ assertNotNull(beacon.toJoinChannelSet(BeaconJoinOption.SWITCH, radioLora)?.lora_config, "SWITCH carries lora")
+ assertNull(beacon.toJoinChannelSet(BeaconJoinOption.NONE, radioLora))
+ }
+
+ @Test
+ fun `SWITCH preserves current lora fields and only overrides preset region and channel_num`() {
+ // Regression: the beacon proto advertises no hop_limit/tx_power/tx_enabled — a switch must not zero them.
+ val current =
+ radioLora.copy(
+ modem_preset = ModemPreset.MEDIUM_FAST,
+ region = RegionCode.EU_868,
+ hop_limit = 3,
+ tx_power = 27,
+ tx_enabled = true,
+ channel_num = 5,
+ )
+ val beacon =
+ MeshBeacon(
+ offer_channel = ChannelSettings(name = "PartyNet"),
+ offer_preset = ModemPreset.LONG_FAST,
+ offer_region = RegionCode.US,
+ )
+ val lora = beacon.toJoinChannelSet(BeaconJoinOption.SWITCH, current)?.lora_config
+ assertNotNull(lora)
+ assertEquals(3, lora.hop_limit, "hop_limit must be preserved, not zeroed")
+ assertEquals(27, lora.tx_power, "tx_power must be preserved")
+ assertEquals(true, lora.tx_enabled, "tx_enabled must be preserved")
+ assertEquals(ModemPreset.LONG_FAST, lora.modem_preset, "offered preset applied")
+ assertEquals(RegionCode.US, lora.region, "offered region applied")
+ assertEquals(0, lora.channel_num, "channel_num reset to 0 for frequency derivation")
+ }
+
+ @Test
+ fun `SWITCH without an offered preset still resets channel_num and keeps the current preset`() {
+ // A channel-only beacon (no preset) must still reset channel_num=0 so firmware re-derives the frequency from
+ // the new primary name — otherwise a radio with an explicit channel_num override lands on the wrong slot.
+ val current = radioLora.copy(modem_preset = ModemPreset.MEDIUM_FAST, channel_num = 5)
+ val beacon = MeshBeacon(offer_channel = ChannelSettings(name = "PartyNet"))
+ val lora = beacon.toJoinChannelSet(BeaconJoinOption.SWITCH, current)?.lora_config
+ assertNotNull(lora, "SWITCH must always carry lora so channel_num resets")
+ assertEquals(0, lora.channel_num, "channel_num reset to 0 even without an offered preset")
+ assertEquals(ModemPreset.MEDIUM_FAST, lora.modem_preset, "current preset kept when none offered")
+ }
+
+ @Test
+ fun `join strips position sharing from the offered channel`() {
+ // Privacy: joining a stranger's mesh must never broadcast our location (Apple sets positionPrecision=0).
+ val beacon =
+ MeshBeacon(
+ offer_channel =
+ ChannelSettings(
+ name = "PartyNet",
+ module_settings = org.meshtastic.proto.ModuleSettings(position_precision = 32),
+ ),
+ offer_preset = ModemPreset.LONG_FAST,
+ offer_region = RegionCode.US,
+ )
+ val added = beacon.toJoinChannelSet(BeaconJoinOption.ADD, radioLora)?.settings?.first()
+ val switched = beacon.toJoinChannelSet(BeaconJoinOption.SWITCH, radioLora)?.settings?.first()
+ assertEquals(0, added?.module_settings?.position_precision, "ADD zeroes position precision")
+ assertEquals(0, switched?.module_settings?.position_precision, "SWITCH zeroes position precision")
+ }
+
+ @Test
+ fun `encode then decode round-trips a beacon offer`() {
+ val offer =
+ MeshBeaconOffer(
+ fromNodeNum = 42,
+ beacon =
+ MeshBeacon(
+ message = "Join us",
+ offer_channel = ChannelSettings(name = "PartyNet", psk = "secret".encodeUtf8()),
+ offer_preset = ModemPreset.LONG_FAST,
+ offer_region = RegionCode.US,
+ ),
+ snr = 6.5f,
+ rssi = -70,
+ )
+ val restored = MeshBeaconOffer.decode(offer.encode())
+ assertEquals(offer, restored)
+ }
+
+ @Test
+ fun `decode returns null for a malformed record`() {
+ assertNull(MeshBeaconOffer.decode("not-a-valid-record"))
+ }
+}

diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
index 6450727d78..bb65c4f04b 100644
--- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
@@ -158,6 +158,8 @@ sealed interface SettingsRoute : Route {
@Serializable data object TAK : SettingsRoute
+ @Serializable data object MeshBeacon : SettingsRoute
+
// endregion
// region advanced config routes

diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/discovery/MeshBeaconPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/discovery/MeshBeaconPrefsImpl.kt
new file mode 100644
index 0000000000..f886e7eeab
--- /dev/null
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/discovery/MeshBeaconPrefsImpl.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.prefs.discovery
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.koin.core.annotation.Named
+import org.koin.core.annotation.Single
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.MeshBeaconPrefs
+
+@Single
+class MeshBeaconPrefsImpl(
+ @Named("UiDataStore") private val dataStore: DataStore<Preferences>,
+ dispatchers: CoroutineDispatchers,
+) : MeshBeaconPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ override val storedBeacons: StateFlow<List<String>> =
+ dataStore.data
+ .map { prefs ->
+ val raw = prefs[KEY_STORED_BEACONS] ?: ""
+ if (raw.isBlank()) emptyList() else raw.split(RECORD_DELIMITER)
+ }
+ .stateIn(scope, SharingStarted.Eagerly, emptyList())
+
+ // Single conflated writer: rapid add()/dismiss() calls each publish the full latest list here, and one collector
+ // serializes the DataStore writes (latest-wins). Avoids launch-per-call races that could persist a stale snapshot.
+ // runCatching keeps a best-effort write failure (e.g. disk I/O) from escaping as an uncaught coroutine exception —
+ // the next add/dismiss retries, and on restart we hydrate from the last successful write.
+ private val pendingWrite = MutableStateFlow<List<String>?>(null)
+
+ init {
+ scope.launch {
+ pendingWrite.filterNotNull().collectLatest { records ->
+ runCatching { dataStore.edit { it[KEY_STORED_BEACONS] = records.joinToString(RECORD_DELIMITER) } }
+ }
+ }
+ }
+
+ override fun setStoredBeacons(records: List<String>) {
+ pendingWrite.value = records
+ }
+
+ private companion object {
+ val KEY_STORED_BEACONS = stringPreferencesKey("mesh_beacon_stored_offers")
+
+ // Newline can never appear in a beacon record (fields are numeric + base64), so it is a safe row separator.
+ const val RECORD_DELIMITER = "\n"
+ }
+}

diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
index e026e78f1d..da9b012182 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -370,3 +370,13 @@ interface DiscoveryPrefs {
const val DEFAULT_DWELL_MINUTES = 15
}
}
+
+/**
+ * Reactive persistence for received Mesh Beacon invitations. Records are opaque, self-describing strings (see
+ * `MeshBeaconOffer.encode`) so this prefs layer stays free of proto/model types.
+ */
+interface MeshBeaconPrefs {
+ val storedBeacons: StateFlow<List<String>>
+
+ fun setStoredBeacons(records: List<String>)
+}

diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshBeaconRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshBeaconRepository.kt
index 3921b4f113..ac5bfb1a6a 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshBeaconRepository.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshBeaconRepository.kt
@@ -16,22 +16,42 @@
*/
package org.meshtastic.core.repository
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
import org.meshtastic.core.model.MeshBeaconOffer
/**
- * Holds Mesh Beacon invitations received during this app session. Beacons are advisory, ephemeral, zero-hop
- * advertisements from other meshes — not messages or contacts — so they're kept in memory only (capped, no persistence)
- * and naturally age out on app restart. Consumed by the Discovery surface, which presents them for the user to Discover
- * / Join / Dismiss. Room persistence can be added later if users want invitations to survive restarts.
+ * Holds Mesh Beacon invitations received from other meshes. Beacons are advisory, zero-hop advertisements — not
+ * messages or contacts — so they live in the Discovery surface, which presents them for the user to Discover / Join /
+ * Dismiss. The list is capped and deduped in memory (the source of truth) and written through to [MeshBeaconPrefs] so
+ * invitations survive an app restart until the user explicitly dismisses them (Apple `014-mesh-beacons` FR-015).
+ *
+ * Note: persisted as opaque delimited records in a Preferences DataStore, not a Room table — the set is tiny (≤
+ * [MAX_OFFERS]) and never queried. Promote to a Room entity alongside the discovery tables if querying/joins appear.
*/
-class MeshBeaconRepository {
+class MeshBeaconRepository(private val prefs: MeshBeaconPrefs, scope: CoroutineScope) {
private val _offers = MutableStateFlow<List<MeshBeaconOffer>>(emptyList())
val offers: StateFlow<List<MeshBeaconOffer>> = _offers.asStateFlow()
+ init {
+ // Hydrate once from disk. Memory is authoritative afterwards, so a live beacon that arrives before the async
+ // DataStore load wins; the guard also makes our own write-through re-emissions no-ops.
+ scope.launch {
+ prefs.storedBeacons.collect { records ->
+ if (records.isEmpty()) return@collect
+ // Fold the empty-guard into the atomic update so a live beacon that arrived first (or a concurrent
+ // add/dismiss) is never clobbered by the async DataStore snapshot.
+ _offers.update { current ->
+ if (current.isEmpty()) records.mapNotNull(MeshBeaconOffer::decode).take(MAX_OFFERS) else current
+ }
+ }
+ }
+ }
+
/**
* Records a received [offer], replacing any prior offer with the same [key][MeshBeaconOffer.key] (a re-broadcast of
* a standing invitation). Returns true when this is a newly-seen invitation, so the caller can notify only once
@@ -40,13 +60,17 @@ class MeshBeaconRepository {
fun add(offer: MeshBeaconOffer): Boolean {
val isNew = _offers.value.none { it.key == offer.key }
_offers.update { current -> (listOf(offer) + current.filterNot { it.key == offer.key }).take(MAX_OFFERS) }
+ persist()
return isNew
}
fun dismiss(key: String) {
_offers.update { current -> current.filterNot { it.key == key } }
+ persist()
}
+ private fun persist() = prefs.setStoredBeacons(_offers.value.map { it.encode() })
+
private companion object {
const val MAX_OFFERS = 20
}

diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt
index 4674d45860..262377c752 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt
@@ -19,7 +19,9 @@ package org.meshtastic.core.repository.di
import org.koin.core.annotation.Module
import org.koin.core.annotation.Provided
import org.koin.core.annotation.Single
+import org.meshtastic.core.common.di.ApplicationCoroutineScope
import org.meshtastic.core.repository.HomoglyphPrefs
+import org.meshtastic.core.repository.MeshBeaconPrefs
import org.meshtastic.core.repository.MeshBeaconRepository
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.repository.NodeRepository
@@ -30,7 +32,11 @@ import org.meshtastic.core.repository.usecase.SendMessageUseCaseImpl
@Module
class CoreRepositoryModule {
- @Single fun provideMeshBeaconRepository(): MeshBeaconRepository = MeshBeaconRepository()
+ @Single
+ fun provideMeshBeaconRepository(
+ @Provided meshBeaconPrefs: MeshBeaconPrefs,
+ @Provided applicationScope: ApplicationCoroutineScope,
+ ): MeshBeaconRepository = MeshBeaconRepository(meshBeaconPrefs, applicationScope)
@Single
fun provideSendMessageUseCase(

diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index be31a0daad..b399f79f2f 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -602,10 +602,10 @@
<string name="firmware_edition">Firmware Edition</string>
<string name="firmware_old">The radio firmware is too old to talk to this application. For more information on this see <a href="https://meshtastic.org/docs/getting-started/flashing-firmware">our Firmware Installation guide</a>.</string>
<string name="firmware_recovery_banner">Finish updating %1$s</string>
- <string name="firmware_recovery_button">Resume firmware update</string>
- <string name="firmware_recovery_explanation">This device is in bootloader mode from a firmware update that did not finish. Keep it nearby and it will be re-flashed to complete the update.</string>
<string name="firmware_recovery_ble_failed">Couldn\'t finish the update over Bluetooth. This device\'s stock bootloader can\'t reliably complete an interrupted update over the air. Connect it to a computer with USB and re-flash it using the vendor\'s serial DFU tool (for example adafruit-nrfutil) to recover the device.</string>
+ <string name="firmware_recovery_button">Resume firmware update</string>
<string name="firmware_recovery_dismiss">Dismiss firmware recovery</string>
+ <string name="firmware_recovery_explanation">This device is in bootloader mode from a firmware update that did not finish. Keep it nearby and it will be re-flashed to complete the update.</string>
<string name="firmware_too_old">Firmware update required.</string>
<string name="firmware_update_almost_there">Almost there...</string>
<string name="firmware_update_alpha">Alpha</string>
@@ -923,17 +923,43 @@
<string name="match_any">Match Any | All</string>
<string name="max">Max</string>
<!-- MESH -->
+ <string name="mesh_beacon">Mesh Beacon</string>
+ <string name="mesh_beacon_broadcast">Broadcast a beacon</string>
+ <string name="mesh_beacon_broadcast_summary">Periodically advertise this mesh to nearby nodes</string>
+ <string name="mesh_beacon_channels_title">Beacon channels</string>
+ <string name="mesh_beacon_interval">Broadcast interval (seconds)</string>
+ <string name="mesh_beacon_interval_error">Minimum %1$d seconds</string>
<string name="mesh_beacon_invitations_title">Mesh invitations</string>
+ <string name="mesh_beacon_listen">Listen for beacons</string>
+ <string name="mesh_beacon_listen_summary">Capture invitations advertised by nearby meshes</string>
+ <string name="mesh_beacon_message">Beacon message</string>
+ <string name="mesh_beacon_message_error">Maximum %1$d bytes</string>
<string name="mesh_beacon_notification_body">A nearby mesh invited you to join</string>
<string name="mesh_beacon_notification_title">Mesh invitation</string>
+ <string name="mesh_beacon_offer_add">Add channel</string>
<string name="mesh_beacon_offer_channel">Channel: %1$s</string>
+ <string name="mesh_beacon_offer_channel_key">Offered channel key (base64)</string>
+ <string name="mesh_beacon_offer_channel_name">Offered channel name</string>
<string name="mesh_beacon_offer_discover">Discover</string>
<string name="mesh_beacon_offer_dismiss">Dismiss</string>
<string name="mesh_beacon_offer_from_unknown">From an unknown node</string>
<string name="mesh_beacon_offer_join">Join</string>
<string name="mesh_beacon_offer_preset">Preset: %1$s</string>
+ <string name="mesh_beacon_offer_preset_setting">Offered preset</string>
<string name="mesh_beacon_offer_region">Region: %1$s</string>
+ <string name="mesh_beacon_offer_region_setting">Offered region</string>
+ <string name="mesh_beacon_offer_signal">Signal: %1$s / %2$s</string>
<string name="mesh_beacon_offer_title">Mesh invitation</string>
+ <string name="mesh_beacon_on_preset">Transmit preset</string>
+ <string name="mesh_beacon_on_region">Transmit region</string>
+ <string name="mesh_beacon_preset_indicator">Advertised by a nearby beacon</string>
+ <string name="mesh_beacon_preset_none">None</string>
+ <string name="mesh_beacon_send_as_node">Broadcast as node number (0 = this node)</string>
+ <string name="mesh_beacon_target">Target %1$d</string>
+ <string name="mesh_beacon_target_add">Add target</string>
+ <string name="mesh_beacon_target_channel_index">Channel index</string>
+ <string name="mesh_beacon_target_remove">Remove target</string>
+ <string name="mesh_beacon_targets">Broadcast targets</string>
<string name="mesh_map_location">Mesh Map Location</string>
<string name="mesh_map_location_description">Enables the blue location dot for your phone in the mesh map.</string>
<!-- MESHTASTIC -->

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt
index bf891a7d16..29346e7c29 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt
@@ -155,8 +155,10 @@ fun ScannedQrCodeDialog(
channelSet.copy(
settings =
channelSet.settings.filterIndexed { i, _ ->
- val isExisting = i < channels.settings.size
- isExisting || channelSelections.getOrNull(i) == true
+ // Primary (index 0) is always kept; existing secondaries can be dropped to free a slot for the
+ // incoming channel when the radio is full (Apple FR-017 "replace a secondary, never the
+ // primary").
+ i == 0 || channelSelections.getOrNull(i) == true
},
)
}
@@ -232,13 +234,16 @@ fun ScannedQrCodeDialog(
index,
channel,
->
- val isExisting = !shouldReplace && index < channels.settings.size
+ val isPrimary = index == 0
val channelObj = Channel(channel, channelSet.lora_config ?: Channel.default.loraConfig)
ChannelSelection(
index = index,
title = channel.name.ifEmpty { modemPresetName },
- enabled = !isExisting,
- isSelected = if (isExisting) true else channelSelections[index],
+ // Primary is always kept. In ADD mode existing secondaries become deselectable so the user can
+ // drop one to make room for the incoming channel on a full radio; REPLACE keeps its prior
+ // all-selectable behavior.
+ enabled = if (shouldReplace) true else !isPrimary,
+ isSelected = if (!shouldReplace && isPrimary) true else channelSelections[index],
onSelected = {
if (it || selectedChannelSet.settings.size > 1) {
channelSelections[index] = it

diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt
index 269d6ef3b2..4a8617adf6 100644
--- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt
@@ -52,6 +52,8 @@ import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.feature.discovery.ai.DiscoverySummaryAiProvider
+import org.meshtastic.proto.Channel
+import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
@@ -101,6 +103,12 @@ class DiscoveryScanEngine(
private var scanScope: CoroutineScope? = null
private var dwellJob: Job? = null
private var originalLoRaConfig: Config.LoRaConfig? = null
+
+ /** The radio's primary channel at scan start; restored after a custom-channel target overwrites it (FR-005). */
+ private var originalPrimaryChannel: ChannelSettings? = null
+
+ /** True once a custom-channel target has retuned the primary channel, so restore only writes when needed. */
+ private var tunedPrimaryChannel: Boolean = false
private var sessionId: Long = 0
/** Nodes collected for the current preset dwell. Keyed by nodeNum. */
@@ -138,14 +146,19 @@ class DiscoveryScanEngine(
// region Public API
+ /** Convenience overload: scan a list of public presets (each becomes a preset [ScanTarget] labelled by name). */
+ suspend fun startScan(presets: List<ChannelOption>, dwellDurationSeconds: Long) =
+ startScanTargets(presets.map { ScanTarget(preset = it, label = it.name) }, dwellDurationSeconds)
+
/**
- * Starts a discovery scan across the given [presets].
+ * Starts a discovery scan across the given [targets] — public presets and/or beacon-advertised custom channels.
+ * (Distinct name from the [ChannelOption] overload: both would erase to the same JVM signature otherwise.)
*
- * @param presets The LoRa presets to cycle through.
- * @param dwellDurationSeconds How long to listen on each preset.
+ * @param targets The scan queue ([ScanTarget]).
+ * @param dwellDurationSeconds How long to listen on each target.
*/
- suspend fun startScan(presets: List<ChannelOption>, dwellDurationSeconds: Long) {
- require(presets.isNotEmpty()) { "At least one preset is required" }
+ suspend fun startScanTargets(targets: List<ScanTarget>, dwellDurationSeconds: Long) {
+ require(targets.isNotEmpty()) { "At least one scan target is required" }
require(dwellDurationSeconds > 0) { "Dwell duration must be positive" }
mutex.withLock {
@@ -156,9 +169,19 @@ class DiscoveryScanEngine(
_scanState.value = DiscoveryScanState.Preparing
- // Capture the entire original LoRa config to restore it accurately later
+ // Capture the entire original LoRa config and primary channel to restore them accurately later.
val initialLoraConfig = radioConfigRepository.localConfigFlow.first().lora
originalLoRaConfig = initialLoraConfig
+ originalPrimaryChannel = radioConfigRepository.channelSetFlow.first().settings.firstOrNull()
+ tunedPrimaryChannel = false
+
+ // A custom-channel target overwrites the primary channel; without a captured original we could not restore
+ // it and would strand the radio on the beacon's channel. Abort rather than proceed silently.
+ if (originalPrimaryChannel == null && targets.any { it.channel != null }) {
+ Logger.w { "DiscoveryScanEngine: primary channel not captured; aborting custom-channel scan" }
+ _scanState.value = DiscoveryScanState.Idle
+ return
+ }
val homePresetStr =
if (initialLoraConfig?.use_preset == true) {
@@ -176,7 +199,7 @@ class DiscoveryScanEngine(
val session =
DiscoverySessionEntity(
timestamp = nowMillis,
- presetsScanned = presets.joinToString(",") { it.name },
+ presetsScanned = targets.joinToString(",") { it.label },
homePreset = homePresetStr,
completionStatus = "in_progress",
userLatitude = latDouble,
@@ -189,14 +212,14 @@ class DiscoveryScanEngine(
collectorRegistry.collector = this
// Set initial state so the scan loop's isActive guard succeeds
- _scanState.value = DiscoveryScanState.Shifting(presets.first().name)
- currentPresetName = presets.first().name
+ _scanState.value = DiscoveryScanState.Shifting(targets.first().label)
+ currentPresetName = targets.first().label
totalDwellSeconds = dwellDurationSeconds
// Launch scan coroutine
val scope = CoroutineScope(dispatchers.io + SupervisorJob())
scanScope = scope
- scope.launch { runScanLoop(presets, dwellDurationSeconds) }
+ scope.launch { runScanLoop(targets, dwellDurationSeconds) }
}
}
@@ -272,11 +295,11 @@ class DiscoveryScanEngine(
// region Scan loop
@Suppress("ReturnCount")
- private suspend fun runScanLoop(presets: List<ChannelOption>, dwellDurationSeconds: Long) {
- for (preset in presets) {
+ private suspend fun runScanLoop(targets: List<ScanTarget>, dwellDurationSeconds: Long) {
+ for (target in targets) {
if (!isActive) return
- currentPresetName = preset.name
+ currentPresetName = target.label
mutex.withLock {
collectedNodes.clear()
deviceMetricsLog.clear()
@@ -284,12 +307,12 @@ class DiscoveryScanEngine(
}
totalDwellSeconds = dwellDurationSeconds
- // Shift to the new preset
- _scanState.value = DiscoveryScanState.Shifting(preset.name)
- shiftPreset(preset)
+ // Shift to the new target (preset, plus a custom primary channel for beacon-channel targets)
+ _scanState.value = DiscoveryScanState.Shifting(target.label)
+ shiftTarget(target)
// Wait for reconnection
- _scanState.value = DiscoveryScanState.Reconnecting(preset.name)
+ _scanState.value = DiscoveryScanState.Reconnecting(target.label)
if (!waitForConnection()) {
pauseAndAbort()
return
@@ -299,13 +322,13 @@ class DiscoveryScanEngine(
requestNeighborInfoAtDwellBoundary()
// Dwell
- if (!runDwell(preset.name, dwellDurationSeconds)) {
+ if (!runDwell(target.label, dwellDurationSeconds)) {
pauseAndAbort()
return
}
if (!isActive) return
- // Persist this preset's results
+ // Persist this target's results
persistCurrentDwellResults()
}
@@ -333,11 +356,36 @@ class DiscoveryScanEngine(
cancelScanInternal()
}
- private suspend fun shiftPreset(preset: ChannelOption) {
- val loraConfig = Config.LoRaConfig(use_preset = true, modem_preset = preset.modemPreset)
- val config = Config(lora = loraConfig)
- radioController.setLocalConfig(config)
- Logger.i { "DiscoveryScanEngine: shifted to ${preset.name} (use_preset=true)" }
+ private suspend fun shiftTarget(target: ScanTarget) {
+ // Start from the captured original config so unrelated fields (hop_limit, tx_power, tx_enabled, …) are carried
+ // over instead of zeroed by a fresh LoRaConfig — a from-scratch config can e.g. break the dwell-boundary
+ // NeighborInfo request. Only the preset (and, for custom channels, region + channel_num) is overridden.
+ val base = originalLoRaConfig ?: Config.LoRaConfig()
+ if (target.channel == null) {
+ // Public-preset target — dwell on the preset using the radio's existing primary channel (unchanged).
+ radioController.setLocalConfig(
+ Config(lora = base.copy(use_preset = true, modem_preset = target.preset.modemPreset)),
+ )
+ Logger.i { "DiscoveryScanEngine: shifted to ${target.label} (use_preset=true)" }
+ } else {
+ // Beacon custom-channel target: apply the offered preset+region, reset channel_num so firmware derives the
+ // frequency from the new name, then tune the primary channel to the offered name+PSK so nodes on that mesh
+ // are heard. The original primary channel is restored after the scan.
+ radioController.setLocalConfig(
+ Config(
+ lora =
+ base.copy(
+ use_preset = true,
+ modem_preset = target.preset.modemPreset,
+ region = target.region ?: base.region,
+ channel_num = 0,
+ ),
+ ),
+ )
+ radioController.setLocalChannel(Channel(index = 0, role = Channel.Role.PRIMARY, settings = target.channel))
+ tunedPrimaryChannel = true
+ Logger.i { "DiscoveryScanEngine: shifted to ${target.label} (custom channel)" }
+ }
// The firmware often restarts the radio or reboots after a LoRa config change.
// Wait a short moment to ensure we don't consider it 'connected' right before it drops.
delay(3000)
@@ -642,8 +690,14 @@ class DiscoveryScanEngine(
private suspend fun restoreHomePreset() {
val config = originalLoRaConfig ?: return
- val fullConfig = Config(lora = config)
- radioController.setLocalConfig(fullConfig)
+ // Restore the primary channel first (only when a custom-channel target overwrote it), then the LoRa config —
+ // both are no-ops on a public-only scan, so that path stays unchanged (FR-005 no-regression).
+ if (tunedPrimaryChannel) {
+ originalPrimaryChannel?.let {
+ radioController.setLocalChannel(Channel(index = 0, role = Channel.Role.PRIMARY, settings = it))
+ }
+ }
+ radioController.setLocalConfig(Config(lora = config))
Logger.i { "DiscoveryScanEngine: restored original LoRa config" }
// The firmware often restarts the radio or reboots after a LoRa config change.
delay(3000)

diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryViewModel.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryViewModel.kt
index 3b8cb5a0dc..1dfb68da74 100644
--- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryViewModel.kt
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryViewModel.kt
@@ -36,6 +36,8 @@ import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.discovery.scan.Check24GhzCapability
import org.meshtastic.feature.discovery.scan.HardwareCapabilityResult
+import org.meshtastic.proto.ChannelSettings
+import org.meshtastic.proto.Config.LoRaConfig
import org.meshtastic.proto.Config.LoRaConfig.RegionCode
@KoinViewModel
@@ -56,6 +58,45 @@ class DiscoveryViewModel(
/** Mesh Beacon invitations received from other meshes, newest first. */
val beaconOffers: StateFlow<List<MeshBeaconOffer>> = meshBeaconRepository.offers
+ /** Modem presets advertised by any received beacon — flagged in the scan-setup picker (FR-004). */
+ val beaconPresets: StateFlow<Set<ChannelOption>> =
+ meshBeaconRepository.offers
+ .map { offers -> offers.mapNotNull { ChannelOption.from(it.beacon.offer_preset) }.toSet() }
+ .stateInWhileSubscribed(initialValue = emptySet())
+
+ /** Distinct custom channels advertised by beacons — offered as selectable scan targets (FR-007). */
+ val beaconChannels: StateFlow<List<BeaconChannel>> =
+ meshBeaconRepository.offers
+ .map { offers ->
+ offers
+ .mapNotNull { offer ->
+ val ch = offer.beacon.offer_channel ?: return@mapNotNull null
+ val name =
+ ch.name.ifBlank {
+ return@mapNotNull null
+ }
+ BeaconChannel(
+ name = name,
+ psk = ch.psk,
+ preset = ChannelOption.from(offer.beacon.offer_preset),
+ region = offer.beacon.offer_region,
+ )
+ }
+ .distinctBy { it.id }
+ }
+ .stateInWhileSubscribed(initialValue = emptyList())
+
+ private val _selectedBeaconChannels = MutableStateFlow<Set<String>>(emptySet())
+ val selectedBeaconChannels: StateFlow<Set<String>> = _selectedBeaconChannels.asStateFlow()
+
+ /** The radio's current LoRa config — drives the add-vs-switch decision for a beacon join. */
+ val currentLora: StateFlow<LoRaConfig?> =
+ radioConfigRepository.localConfigFlow.map { it.lora }.stateInWhileSubscribed(initialValue = null)
+
+ /** The radio's current channel settings (index 0 = primary) — drives the add-vs-switch decision. */
+ val currentChannels: StateFlow<List<ChannelSettings>> =
+ radioConfigRepository.channelSetFlow.map { it.settings }.stateInWhileSubscribed(initialValue = emptyList())
+
val homePreset: StateFlow<ChannelOption> =
radioConfigRepository.localConfigFlow
.map { localConfig -> ChannelOption.from(localConfig.lora?.modem_preset) ?: ChannelOption.DEFAULT }
@@ -94,6 +135,12 @@ class DiscoveryViewModel(
val sessions: StateFlow<List<DiscoverySessionEntity>> =
discoveryDao.getAllSessions().stateInWhileSubscribed(initialValue = emptyList())
+ /** Beacon presets we've already auto-selected once, so a user's later deselection is never undone (FR-004). */
+ private val autoSelectedBeaconPresets = mutableSetOf<ChannelOption>()
+
+ /** Beacon channels we've already auto-selected once (union-only, FR-007). */
+ private val autoSelectedBeaconChannels = mutableSetOf<String>()
+
init {
safeLaunch(tag = "markInterruptedSessions") { discoveryDao.markInterruptedSessions() }
safeLaunch(tag = "check24GhzCapability") {
@@ -101,26 +148,57 @@ class DiscoveryViewModel(
_is24GhzBlocked.value =
result is HardwareCapabilityResult.Unsupported || result is HardwareCapabilityResult.Unknown
}
+ // Pre-select each beacon-advertised preset once (union-only) as beacons arrive — never clearing user choices.
+ safeLaunch(tag = "preselectBeaconPresets") {
+ beaconPresets.collect { presets ->
+ val fresh = presets - autoSelectedBeaconPresets
+ if (fresh.isNotEmpty()) {
+ autoSelectedBeaconPresets += fresh
+ updateSelectedPresets { it + fresh }
+ }
+ }
+ }
+ // Pre-select each beacon custom channel once (union-only), never clearing user choices (FR-007).
+ safeLaunch(tag = "preselectBeaconChannels") {
+ beaconChannels.collect { channels ->
+ val fresh = channels.map { it.id }.toSet() - autoSelectedBeaconChannels
+ if (fresh.isNotEmpty()) {
+ autoSelectedBeaconChannels += fresh
+ _selectedBeaconChannels.update { it + fresh }
+ }
+ }
+ }
+ }
+
+ fun toggleBeaconChannel(id: String) {
+ _selectedBeaconChannels.update { if (id in it) it - id else it + id }
}
- fun togglePreset(preset: ChannelOption) {
+ fun togglePreset(preset: ChannelOption) = updateSelectedPresets { current ->
+ if (preset in current) current - preset else current + preset
+ }
+
+ /**
+ * Atomically mutates the selected-preset set and mirrors it to prefs, so the shown selection and saved prefs never
+ * diverge. Shared by [togglePreset], [discoverOffer], and the beacon-preset pre-select.
+ */
+ private fun updateSelectedPresets(transform: (Set<ChannelOption>) -> Set<ChannelOption>) {
_selectedPresets.update { current ->
- val updated = if (preset in current) current - preset else current + preset
+ val updated = transform(current)
discoveryPrefs.setSelectedPresets(updated.map { it.name }.toSet())
updated
}
}
/**
- * Seeds the scan with just the preset an invitation advertised, so the user can survey the advertised mesh before
- * joining. Persists like [togglePreset] (not a bare [_selectedPresets] write) so the shown selection and saved
- * prefs never diverge — otherwise the next [togglePreset] would silently persist prefs derived from this seed.
- * No-op if the offer carries no preset, or a preset with no matching [ChannelOption].
+ * Adds the preset an invitation advertised to the scan selection (union, never clearing the user's existing choices
+ * — matches Apple 014-mesh-beacons FR-003), so the user can survey the advertised mesh before joining. Persists
+ * like [togglePreset] so the shown selection and saved prefs never diverge. No-op if the offer carries no preset,
+ * or a preset with no matching [ChannelOption].
*/
fun discoverOffer(offer: MeshBeaconOffer) {
val preset = ChannelOption.from(offer.beacon.offer_preset) ?: return
- _selectedPresets.value = setOf(preset)
- discoveryPrefs.setSelectedPresets(setOf(preset.name))
+ updateSelectedPresets { it + preset }
}
fun dismissOffer(offer: MeshBeaconOffer) {
@@ -134,8 +212,24 @@ class DiscoveryViewModel(
fun startScan() {
safeLaunch(tag = "startScan") {
- scanEngine.startScan(
- presets = selectedPresets.value.toList(),
+ val presetTargets = selectedPresets.value.map { ScanTarget(preset = it, label = it.name) }
+ val selectedIds = selectedBeaconChannels.value
+ val channelTargets =
+ beaconChannels.value
+ .filter { it.id in selectedIds }
+ .map { bc ->
+ val preset = bc.preset ?: homePreset.value
+ ScanTarget(
+ preset = preset,
+ label = "${preset.name} · ${bc.name}",
+ channel = ChannelSettings(name = bc.name, psk = bc.psk),
+ region = bc.region.takeIf { it != RegionCode.UNSET },
+ )
+ }
+ val targets = presetTargets + channelTargets
+ if (targets.isEmpty()) return@safeLaunch
+ scanEngine.startScanTargets(
+ targets = targets,
dwellDurationSeconds = dwellDurationMinutes.value.toLong() * SECONDS_PER_MINUTE,
)
}

diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ScanTarget.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ScanTarget.kt
new file mode 100644
index 0000000000..115041fb53
--- /dev/null
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ScanTarget.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.discovery
+
+import okio.ByteString
+import org.meshtastic.core.model.ChannelOption
+import org.meshtastic.proto.ChannelSettings
+import org.meshtastic.proto.Config.LoRaConfig.RegionCode
+
+/**
+ * One entry in the discovery scan queue. A [channel] of `null` is a public-preset target (the original scan behavior —
+ * dwell on [preset] using the radio's existing primary channel). A non-null [channel] is a beacon-advertised custom
+ * channel: during its dwell the engine tunes the radio's primary channel to that name+PSK (and [region]) so nodes on
+ * that mesh are heard, then restores the original primary channel afterwards (Apple 014-mesh-beacons FR-005).
+ *
+ * @param label Result label persisted with the dwell (e.g. `"LONG_FAST"` or `"LONG_FAST · PartyNet"`), so a custom
+ * channel's results never collide with the same preset's public results.
+ */
+data class ScanTarget(
+ val preset: ChannelOption,
+ val label: String,
+ val channel: ChannelSettings? = null,
+ val region: RegionCode? = null,
+)
+
+/**
+ * A distinct custom channel a beacon advertised, presented as a selectable row in scan setup (FR-007). Deduped by [id]
+ * (name + preset + PSK), since the same channel on the same preset is one row regardless of how many nodes beaconed it
+ * — but a different PSK is a different network, so it must not collapse into another row (that would send the user to
+ * the wrong mesh).
+ */
+data class BeaconChannel(val name: String, val psk: ByteString, val preset: ChannelOption?, val region: RegionCode) {
+ val id: String
+ get() = "$name|${preset?.name.orEmpty()}|${psk.hex()}"
+}

diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/navigation/DiscoveryNavigation.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/navigation/DiscoveryNavigation.kt
index ecb823b22a..7142a7c415 100644
--- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/navigation/DiscoveryNavigation.kt
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/navigation/DiscoveryNavigation.kt
@@ -25,7 +25,6 @@ import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
-import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.navigation.DiscoveryRoute
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.viewmodel.UIViewModel
@@ -81,9 +80,11 @@ fun EntryProviderScope<NavKey>.discoveryGraph(backStack: NavBackStack<NavKey>) {
private fun DiscoveryScanScreenEntry(backStack: NavBackStack<NavKey>) {
val viewModel = koinViewModel<DiscoveryViewModel>()
// Join reuses the QR channel-import flow (same ADD/REPLACE + "this retunes your radio" confirmation a scanned
- // channel URL shows). UIViewModel is ViewModel-store-scoped per nav entry (MeshtasticNavDisplay uses
- // rememberViewModelStoreNavEntryDecorator), so the app-shell SharedDialogs observes a *different* instance —
- // we must render the dialog here, against this entry's instance, or the offer never surfaces.
+ // channel URL shows). The screen decides add-vs-switch and hands us the ready ChannelSet (add = no lora_config →
+ // no reboot; switch = lora_config present → replace + reboot). UIViewModel is ViewModel-store-scoped per nav entry
+ // (MeshtasticNavDisplay uses rememberViewModelStoreNavEntryDecorator), so the app-shell SharedDialogs observes a
+ // *different* instance — we must render the dialog here, against this entry's instance, or the offer never
+ // surfaces.
val uiViewModel = koinViewModel<UIViewModel>()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
DiscoveryScanScreen(
@@ -91,7 +92,7 @@ private fun DiscoveryScanScreenEntry(backStack: NavBackStack<NavKey>) {
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
onNavigateToSummary = { sessionId -> backStack.add(DiscoveryRoute.DiscoverySummary(sessionId)) },
onNavigateToHistory = dropUnlessResumed { backStack.add(DiscoveryRoute.DiscoveryHistory) },
- onJoinOffer = { beacon -> beacon.toChannelSet()?.let(uiViewModel::setRequestChannelSet) },
+ onJoinOffer = uiViewModel::setRequestChannelSet,
)
requestChannelSet?.let { ScannedQrCodeDialog(incoming = it, onDismiss = { uiViewModel.clearRequestChannelUrl() }) }
}

diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryScanScreen.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryScanScreen.kt
index 75613e85a1..9ec723a857 100644
--- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryScanScreen.kt
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryScanScreen.kt
@@ -61,6 +61,8 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.util.beaconJoinOption
+import org.meshtastic.core.model.util.toJoinChannelSet
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.back
import org.meshtastic.core.resources.discovery_analysing_results
@@ -99,10 +101,11 @@ import org.meshtastic.core.ui.icon.Warning
import org.meshtastic.core.ui.util.KeepScreenOn
import org.meshtastic.feature.discovery.DiscoveryScanState
import org.meshtastic.feature.discovery.DiscoveryViewModel
+import org.meshtastic.feature.discovery.ui.component.BeaconChannelsCard
import org.meshtastic.feature.discovery.ui.component.DwellProgressIndicator
import org.meshtastic.feature.discovery.ui.component.MeshBeaconInvitationCard
import org.meshtastic.feature.discovery.ui.component.PresetPickerCard
-import org.meshtastic.proto.MeshBeacon
+import org.meshtastic.proto.ChannelSet
private val CONTENT_PADDING = 16.dp
private val SECTION_SPACING = 16.dp
@@ -119,11 +122,16 @@ fun DiscoveryScanScreen(
onNavigateToSummary: (sessionId: Long) -> Unit,
onNavigateToHistory: () -> Unit,
modifier: Modifier = Modifier,
- onJoinOffer: (MeshBeacon) -> Unit = {},
+ onJoinOffer: (ChannelSet) -> Unit = {},
) {
val scanState by viewModel.scanState.collectAsStateWithLifecycle()
val selectedPresets by viewModel.selectedPresets.collectAsStateWithLifecycle()
val beaconOffers by viewModel.beaconOffers.collectAsStateWithLifecycle()
+ val beaconPresets by viewModel.beaconPresets.collectAsStateWithLifecycle()
+ val beaconChannels by viewModel.beaconChannels.collectAsStateWithLifecycle()
+ val selectedBeaconChannels by viewModel.selectedBeaconChannels.collectAsStateWithLifecycle()
+ val currentLora by viewModel.currentLora.collectAsStateWithLifecycle()
+ val currentChannels by viewModel.currentChannels.collectAsStateWithLifecycle()
val dwellMinutes by viewModel.dwellDurationMinutes.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val usesDefaultKey by viewModel.usesDefaultKey.collectAsStateWithLifecycle()
@@ -184,7 +192,7 @@ fun DiscoveryScanScreen(
ScanButton(
scanState = scanState,
isConnected = isConnected,
- hasPresetsSelected = selectedPresets.isNotEmpty(),
+ hasPresetsSelected = selectedPresets.isNotEmpty() || selectedBeaconChannels.isNotEmpty(),
usesDefaultKey = usesDefaultKey,
is24GhzUnsupported = isLora24Region && is24GhzBlocked,
onStart = viewModel::startScan,
@@ -215,9 +223,14 @@ fun DiscoveryScanScreen(
)
}
items(beaconOffers, key = { "invitation_${it.key}" }) { offer ->
+ val joinOption =
+ remember(offer, currentLora, currentChannels) {
+ offer.beacon.beaconJoinOption(currentLora, currentChannels)
+ }
MeshBeaconInvitationCard(
offer = offer,
- onJoin = { onJoinOffer(offer.beacon) },
+ joinOption = joinOption,
+ onJoin = { offer.beacon.toJoinChannelSet(joinOption, currentLora)?.let(onJoinOffer) },
onDiscover = { viewModel.discoverOffer(offer) },
onDismiss = { viewModel.dismissOffer(offer) },
)
@@ -231,9 +244,22 @@ fun DiscoveryScanScreen(
homePreset = homePreset,
onTogglePreset = viewModel::togglePreset,
enabled = true,
+ beaconPresets = beaconPresets,
)
}
+ // Beacon channels — custom channels advertised by beacons (hidden when none recorded)
+ if (beaconChannels.isNotEmpty()) {
+ item(key = "beacon_channels") {
+ BeaconChannelsCard(
+ channels = beaconChannels,
+ selectedIds = selectedBeaconChannels,
+ onToggle = viewModel::toggleBeaconChannel,
+ enabled = true,
+ )
+ }
+ }
+
// Dwell time picker
item(key = "dwell_picker") {
DwellTimePicker(

diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/BeaconChannelsCard.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/BeaconChannelsCard.kt
new file mode 100644
index 0000000000..63d9964029
--- /dev/null
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/BeaconChannelsCard.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.discovery.ui.component
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.mesh_beacon_channels_title
+import org.meshtastic.core.resources.mesh_beacon_preset_indicator
+import org.meshtastic.core.ui.icon.CellTower
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.feature.discovery.BeaconChannel
+
+private val CARD_PADDING = 16.dp
+private val ROW_SPACING = 8.dp
+
+/**
+ * Scan-setup section listing distinct custom channels that beacons advertised (Apple 014-mesh-beacons FR-007).
+ * Selecting a row adds a custom-channel scan target that tunes the radio's primary channel to that name during its
+ * dwell.
+ */
+@Composable
+fun BeaconChannelsCard(
+ channels: List<BeaconChannel>,
+ selectedIds: Set<String>,
+ onToggle: (String) -> Unit,
+ enabled: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ ElevatedCard(modifier = modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(CARD_PADDING), verticalArrangement = Arrangement.spacedBy(ROW_SPACING)) {
+ Text(
+ text = stringResource(Res.string.mesh_beacon_channels_title),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.semantics { heading() },
+ )
+ channels.forEach { channel ->
+ val selected = channel.id in selectedIds
+ Row(
+ modifier = Modifier.fillMaxWidth().clickable(enabled = enabled) { onToggle(channel.id) },
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(ROW_SPACING),
+ ) {
+ Checkbox(checked = selected, onCheckedChange = { onToggle(channel.id) }, enabled = enabled)
+ Icon(
+ imageVector = MeshtasticIcons.CellTower,
+ contentDescription = stringResource(Res.string.mesh_beacon_preset_indicator),
+ )
+ Column {
+ Text(text = channel.name, style = MaterialTheme.typography.bodyMedium)
+ channel.preset?.let {
+ Text(
+ text = it.displayName(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}

diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt
index 779f7af1c2..23ae7ff3e9 100644
--- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt
@@ -33,9 +33,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.model.ChannelOption
import org.meshtastic.core.model.MeshBeaconOffer
+import org.meshtastic.core.model.util.BeaconJoinOption
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.mesh_beacon_offer_add
import org.meshtastic.core.resources.mesh_beacon_offer_channel
import org.meshtastic.core.resources.mesh_beacon_offer_discover
import org.meshtastic.core.resources.mesh_beacon_offer_dismiss
@@ -43,6 +46,7 @@ import org.meshtastic.core.resources.mesh_beacon_offer_from_unknown
import org.meshtastic.core.resources.mesh_beacon_offer_join
import org.meshtastic.core.resources.mesh_beacon_offer_preset
import org.meshtastic.core.resources.mesh_beacon_offer_region
+import org.meshtastic.core.resources.mesh_beacon_offer_signal
import org.meshtastic.core.resources.mesh_beacon_offer_title
import org.meshtastic.proto.Config.LoRaConfig.RegionCode
@@ -50,9 +54,11 @@ import org.meshtastic.proto.Config.LoRaConfig.RegionCode
* A single received Mesh Beacon invitation. Presents the advertised channel/region/preset and lets the user survey the
* mesh first ([onDiscover], shown only when a preset is offered), join it ([onJoin]), or dismiss the invitation.
*/
+@Suppress("LongMethod")
@Composable
internal fun MeshBeaconInvitationCard(
offer: MeshBeaconOffer,
+ joinOption: BeaconJoinOption,
onJoin: () -> Unit,
onDiscover: () -> Unit,
onDismiss: () -> Unit,
@@ -93,6 +99,18 @@ internal fun MeshBeaconInvitationCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
+ if (offer.rssi != 0 || offer.snr != 0f) {
+ Text(
+ text =
+ stringResource(
+ Res.string.mesh_beacon_offer_signal,
+ MetricFormatter.snr(offer.snr),
+ MetricFormatter.rssi(offer.rssi),
+ ),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
@@ -104,7 +122,17 @@ internal fun MeshBeaconInvitationCard(
if (presetOption != null) {
OutlinedButton(onClick = onDiscover) { Text(stringResource(Res.string.mesh_beacon_offer_discover)) }
}
- Button(onClick = onJoin) { Text(stringResource(Res.string.mesh_beacon_offer_join)) }
+ // "Add channel" joins with no reboot (same frequency slot); otherwise "Join" retunes + reboots.
+ val joinLabel =
+ if (joinOption == BeaconJoinOption.ADD) {
+ Res.string.mesh_beacon_offer_add
+ } else {
+ Res.string.mesh_beacon_offer_join
+ }
+ // NONE (no offered channel) can't be joined — toJoinChannelSet returns null — so disable the action.
+ Button(onClick = onJoin, enabled = joinOption != BeaconJoinOption.NONE) {
+ Text(stringResource(joinLabel))
+ }
}
}
}

diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/PresetPickerCard.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/PresetPickerCard.kt
index 0f9039fd70..b6f9c206d0 100644
--- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/PresetPickerCard.kt
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/PresetPickerCard.kt
@@ -43,6 +43,8 @@ import org.meshtastic.core.resources.discovery_lora_presets_description
import org.meshtastic.core.resources.discovery_preset_home_label
import org.meshtastic.core.resources.discovery_stat_selected
import org.meshtastic.core.resources.discovery_stat_unselected
+import org.meshtastic.core.resources.mesh_beacon_preset_indicator
+import org.meshtastic.core.ui.icon.CellTower
import org.meshtastic.core.ui.icon.Check
import org.meshtastic.core.ui.icon.MeshtasticIcons
@@ -58,6 +60,7 @@ internal fun ChannelOption.displayName(): String =
private val DEPRECATED_PRESETS = setOf(ChannelOption.VERY_LONG_SLOW, ChannelOption.LONG_SLOW)
/** A card containing a [FlowRow] of [FilterChip] items for preset selection. */
+@Suppress("LongMethod")
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PresetPickerCard(
@@ -66,6 +69,7 @@ fun PresetPickerCard(
onTogglePreset: (ChannelOption) -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier,
+ beaconPresets: Set<ChannelOption> = emptySet(),
) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(CARD_PADDING)) {
@@ -98,6 +102,8 @@ fun PresetPickerCard(
}
val selectedDesc = stringResource(Res.string.discovery_stat_selected)
val unselectedDesc = stringResource(Res.string.discovery_stat_unselected)
+ val fromBeacon = preset in beaconPresets
+ val beaconDesc = stringResource(Res.string.mesh_beacon_preset_indicator)
FilterChip(
selected = selected,
onClick = { onTogglePreset(preset) },
@@ -119,6 +125,19 @@ fun PresetPickerCard(
} else {
null
},
+ // A tower badge marks presets a nearby beacon advertised (Apple 014-mesh-beacons FR-004).
+ trailingIcon =
+ if (fromBeacon) {
+ {
+ Icon(
+ imageVector = MeshtasticIcons.CellTower,
+ contentDescription = beaconDesc,
+ modifier = Modifier.size(FilterChipDefaults.IconSize),
+ )
+ }
+ } else {
+ null
+ },
)
}
}

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt
index a5766e253d..fcd518f006 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt
@@ -40,6 +40,7 @@ import org.meshtastic.core.resources.ic_speed
import org.meshtastic.core.resources.ic_terminal
import org.meshtastic.core.resources.ic_usb
import org.meshtastic.core.resources.ic_volume_up
+import org.meshtastic.core.resources.mesh_beacon
import org.meshtastic.core.resources.mqtt
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.paxcounter
@@ -61,6 +62,9 @@ enum class ModuleRoute(
val type: Int = 0,
val isSupported: (Capabilities) -> Boolean = { true },
val isApplicable: (Config.DeviceConfig.Role?) -> Boolean = { true },
+ // False when the firmware has no ModuleConfigType to request this module per-request; the editor then relies on the
+ // connect-time config sync instead of a get (MeshBeacon: MeshBeaconConfig is in ModuleConfig but not in the enum).
+ val refreshable: Boolean = true,
) {
MQTT(Res.string.mqtt, SettingsRoute.MQTT, Res.drawable.ic_cloud, AdminMessage.ModuleConfigType.MQTT_CONFIG.value),
SERIAL(
@@ -150,6 +154,16 @@ enum class ModuleRoute(
isSupported = { it.supportsTakConfig },
isApplicable = { it == Config.DeviceConfig.Role.TAK || it == Config.DeviceConfig.Role.TAK_TRACKER },
),
+
+ // MeshBeaconConfig has no AdminMessage.ModuleConfigType value upstream — the editor reads from the connect-time
+ // config sync, so refreshable=false (no per-module get is issued). Gated to firmware that ships the beacon module.
+ MESH_BEACON(
+ Res.string.mesh_beacon,
+ SettingsRoute.MeshBeacon,
+ Res.drawable.ic_perm_scan_wifi,
+ isSupported = { it.supportsMeshBeacon },
+ refreshable = false,
+ ),
;
val bitfield: Int
@@ -184,7 +198,11 @@ enum class ModuleRoute(
STATUS_MESSAGE -> 0x0000
// Not excludable yet
- TAK -> 0x0000 // Not excludable yet
+ TAK -> 0x0000
+
+ // Not excludable yet
+
+ MESH_BEACON -> 0x0000 // Not excludable yet
}
companion object {

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
index 9725c1ced4..31609ba0ba 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
@@ -57,6 +57,7 @@ import org.meshtastic.feature.settings.radio.component.DisplayConfigScreen
import org.meshtastic.feature.settings.radio.component.ExternalNotificationConfigScreenCommon
import org.meshtastic.feature.settings.radio.component.LoRaConfigScreen
import org.meshtastic.feature.settings.radio.component.MQTTConfigScreen
+import org.meshtastic.feature.settings.radio.component.MeshBeaconConfigScreen
import org.meshtastic.feature.settings.radio.component.NeighborInfoConfigScreen
import org.meshtastic.feature.settings.radio.component.NetworkConfigScreen
import org.meshtastic.feature.settings.radio.component.PaxcounterConfigScreen
@@ -214,6 +215,9 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
ModuleRoute.TAK ->
TAKConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() })
+
+ ModuleRoute.MESH_BEACON ->
+ MeshBeaconConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() })
}
}
}

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
index 0fa1135e5a..199808db25 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
@@ -585,6 +585,13 @@ open class RadioConfigViewModel(
fun setResponseStateLoading(route: Enum<*>) {
val destNum = destNum ?: destNode.value?.num ?: return
+ // A module without a per-module get (no ModuleConfigType, e.g. MeshBeacon) reads from the connect-time config
+ // sync — just select the route and render, skipping the loading/request round-trip that would never complete.
+ if (route is ModuleRoute && !route.refreshable) {
+ _radioConfigState.update { it.copy(route = route.name, responseState = ResponseState.Empty) }
+ return
+ }
+
_radioConfigState.update { it.copy(route = route.name, responseState = ResponseState.Loading()) }
when (route) {

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigItemList.kt
new file mode 100644
index 0000000000..03e8015647
--- /dev/null
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigItemList.kt
@@ -0,0 +1,292 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.settings.radio.component
+
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import okio.ByteString.Companion.decodeBase64
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.mesh_beacon
+import org.meshtastic.core.resources.mesh_beacon_broadcast
+import org.meshtastic.core.resources.mesh_beacon_broadcast_summary
+import org.meshtastic.core.resources.mesh_beacon_interval
+import org.meshtastic.core.resources.mesh_beacon_interval_error
+import org.meshtastic.core.resources.mesh_beacon_listen
+import org.meshtastic.core.resources.mesh_beacon_listen_summary
+import org.meshtastic.core.resources.mesh_beacon_message
+import org.meshtastic.core.resources.mesh_beacon_offer_channel_key
+import org.meshtastic.core.resources.mesh_beacon_offer_channel_name
+import org.meshtastic.core.resources.mesh_beacon_offer_preset_setting
+import org.meshtastic.core.resources.mesh_beacon_offer_region_setting
+import org.meshtastic.core.resources.mesh_beacon_on_preset
+import org.meshtastic.core.resources.mesh_beacon_on_region
+import org.meshtastic.core.resources.mesh_beacon_send_as_node
+import org.meshtastic.core.resources.mesh_beacon_target_add
+import org.meshtastic.core.resources.mesh_beacon_target_channel_index
+import org.meshtastic.core.resources.mesh_beacon_target_remove
+import org.meshtastic.core.resources.mesh_beacon_targets
+import org.meshtastic.core.ui.component.DropDownPreference
+import org.meshtastic.core.ui.component.EditTextPreference
+import org.meshtastic.core.ui.component.SignedIntegerEditTextPreference
+import org.meshtastic.core.ui.component.SwitchPreference
+import org.meshtastic.core.ui.component.TitledCard
+import org.meshtastic.feature.settings.radio.RadioConfigViewModel
+import org.meshtastic.proto.ChannelSettings
+import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
+import org.meshtastic.proto.ModuleConfig
+import org.meshtastic.proto.ModuleConfig.MeshBeaconConfig
+
+private const val MESSAGE_MAX_BYTES = 100
+private const val CHANNEL_NAME_MAX_BYTES = 11 // ChannelSettings.name max_size:12 (buffer incl. null terminator)
+private const val MIN_INTERVAL_SECS = 3600
+
+private fun Int.withFlag(flag: Int, on: Boolean): Int = if (on) this or flag else this and flag.inv()
+
+private fun Int.hasFlag(flag: Int): Boolean = (this and flag) != 0
+
+/**
+ * Editor for `ModuleConfig.MeshBeaconConfig` (Apple 014-mesh-beacons US2 / FR-009–FR-014). Reads from the connect-time
+ * config sync (there is no `ModuleConfigType` beacon value to request per-module) and writes via
+ * `AdminMessage.setModuleConfig`. Flag edits are read-modify-write so `FLAG_LEGACY_SPLIT` and any unknown bits survive.
+ *
+ * The repeated `broadcast_targets` list is editable ([BroadcastTargetsCard]); the single-target `broadcast_on_*`
+ * scalars are shown only as the fallback when no targets are set (Apple parity). `broadcast_on_channel` is preserved
+ * verbatim through the Wire `copy()` round-trip (no scalar editor — the targets list supersedes it).
+ */
+@Suppress("LongMethod")
+@Composable
+fun MeshBeaconConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit, modifier: Modifier = Modifier) {
+ val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
+ val meshBeaconConfig = state.moduleConfig.mesh_beacon ?: MeshBeaconConfig()
+ val formState = rememberConfigState(initialValue = meshBeaconConfig)
+
+ val listenFlag = MeshBeaconConfig.Flags.FLAG_LISTEN_ENABLED.value
+ val broadcastFlag = MeshBeaconConfig.Flags.FLAG_BROADCAST_ENABLED.value
+ // Only require a valid interval when broadcasting is actually on — otherwise a default (interval=0) config could
+ // never be saved, blocking even a listen-only toggle (FR-013 applies to the broadcast, not the whole form).
+ val broadcastEnabled = formState.value.flags.hasFlag(broadcastFlag)
+ val intervalValid = !broadcastEnabled || formState.value.broadcast_interval_secs >= MIN_INTERVAL_SECS
+
+ RadioConfigScreenList(
+ modifier = modifier,
+ title = stringResource(Res.string.mesh_beacon),
+ onBack = onBack,
+ configState = formState,
+ // Block saving an out-of-range interval (FR-013); message length is capped inline by the field itself.
+ enabled = state.connected && intervalValid,
+ responseState = state.responseState,
+ onDismissPacketResponse = viewModel::clearPacketResponse,
+ onSave = {
+ // Drop the offered channel when no name is set (a text-only beacon offers no channel).
+ val offered = it.broadcast_offer_channel?.takeIf { c -> c.name.isNotBlank() }
+ viewModel.setModuleConfig(ModuleConfig(mesh_beacon = it.copy(broadcast_offer_channel = offered)))
+ },
+ ) {
+ item {
+ TitledCard(title = stringResource(Res.string.mesh_beacon)) {
+ SwitchPreference(
+ title = stringResource(Res.string.mesh_beacon_listen),
+ summary = stringResource(Res.string.mesh_beacon_listen_summary),
+ checked = formState.value.flags.hasFlag(listenFlag),
+ enabled = state.connected,
+ onCheckedChange = {
+ formState.value = formState.value.copy(flags = formState.value.flags.withFlag(listenFlag, it))
+ },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+ HorizontalDivider()
+ SwitchPreference(
+ title = stringResource(Res.string.mesh_beacon_broadcast),
+ summary = stringResource(Res.string.mesh_beacon_broadcast_summary),
+ checked = formState.value.flags.hasFlag(broadcastFlag),
+ enabled = state.connected,
+ onCheckedChange = {
+ formState.value =
+ formState.value.copy(flags = formState.value.flags.withFlag(broadcastFlag, it))
+ },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+ HorizontalDivider()
+ EditTextPreference(
+ title = stringResource(Res.string.mesh_beacon_message),
+ value = formState.value.broadcast_message,
+ maxSize = MESSAGE_MAX_BYTES,
+ enabled = state.connected,
+ isError = false,
+ keyboardOptions = KeyboardOptions.Default,
+ keyboardActions = KeyboardActions.Default,
+ onValueChanged = { formState.value = formState.value.copy(broadcast_message = it) },
+ )
+ HorizontalDivider()
+ EditTextPreference(
+ title = stringResource(Res.string.mesh_beacon_interval),
+ value = formState.value.broadcast_interval_secs,
+ enabled = state.connected,
+ isError = !intervalValid,
+ keyboardActions = KeyboardActions.Default,
+ summary =
+ if (intervalValid) {
+ null
+ } else {
+ stringResource(Res.string.mesh_beacon_interval_error, MIN_INTERVAL_SECS)
+ },
+ onValueChanged = { formState.value = formState.value.copy(broadcast_interval_secs = it) },
+ )
+ }
+ }
+ item {
+ TitledCard(title = stringResource(Res.string.mesh_beacon_offer_channel_name)) {
+ EditTextPreference(
+ title = stringResource(Res.string.mesh_beacon_offer_channel_name),
+ value = formState.value.broadcast_offer_channel?.name.orEmpty(),
+ maxSize = CHANNEL_NAME_MAX_BYTES,
+ enabled = state.connected,
+ isError = false,
+ keyboardOptions = KeyboardOptions.Default,
+ keyboardActions = KeyboardActions.Default,
+ onValueChanged = {
+ val current = formState.value.broadcast_offer_channel ?: ChannelSettings()
+ formState.value = formState.value.copy(broadcast_offer_channel = current.copy(name = it))
+ },
+ )
+ HorizontalDivider()
+ EditTextPreference(
+ title = stringResource(Res.string.mesh_beacon_offer_channel_key),
+ value = formState.value.broadcast_offer_channel?.psk?.base64().orEmpty(),
+ enabled = state.connected,
+ isError = false,
+ keyboardOptions = KeyboardOptions.Default,
+ keyboardActions = KeyboardActions.Default,
+ onValueChanged = { encoded ->
+ val psk = encoded.decodeBase64() ?: return@EditTextPreference
+ val current = formState.value.broadcast_offer_channel ?: ChannelSettings()
+ formState.value = formState.value.copy(broadcast_offer_channel = current.copy(psk = psk))
+ },
+ )
+ HorizontalDivider()
+ DropDownPreference(
+ title = stringResource(Res.string.mesh_beacon_offer_region_setting),
+ selectedItem = formState.value.broadcast_offer_region,
+ enabled = state.connected,
+ onItemSelected = { formState.value = formState.value.copy(broadcast_offer_region = it) },
+ )
+ HorizontalDivider()
+ DropDownPreference(
+ title = stringResource(Res.string.mesh_beacon_offer_preset_setting),
+ selectedItem = formState.value.broadcast_offer_preset ?: ModemPreset.LONG_FAST,
+ enabled = state.connected,
+ onItemSelected = { formState.value = formState.value.copy(broadcast_offer_preset = it) },
+ )
+ }
+ }
+ item {
+ BroadcastTargetsCard(
+ targets = formState.value.broadcast_targets,
+ enabled = state.connected,
+ onChange = { formState.value = formState.value.copy(broadcast_targets = it) },
+ )
+ }
+ item {
+ TitledCard(title = stringResource(Res.string.mesh_beacon_on_region)) {
+ // Single-target transmit scalars are the fallback used only when no multi-target list is set (Apple
+ // shows these two only when broadcast_targets is empty). broadcast_send_as_node applies either way.
+ if (formState.value.broadcast_targets.isEmpty()) {
+ DropDownPreference(
+ title = stringResource(Res.string.mesh_beacon_on_region),
+ selectedItem = formState.value.broadcast_on_region,
+ enabled = state.connected,
+ onItemSelected = { formState.value = formState.value.copy(broadcast_on_region = it) },
+ )
+ HorizontalDivider()
+ DropDownPreference(
+ title = stringResource(Res.string.mesh_beacon_on_preset),
+ selectedItem = formState.value.broadcast_on_preset ?: ModemPreset.LONG_FAST,
+ enabled = state.connected,
+ onItemSelected = { formState.value = formState.value.copy(broadcast_on_preset = it) },
+ )
+ HorizontalDivider()
+ }
+ SignedIntegerEditTextPreference(
+ title = stringResource(Res.string.mesh_beacon_send_as_node),
+ value = formState.value.broadcast_send_as_node,
+ enabled = state.connected,
+ keyboardActions = KeyboardActions.Default,
+ onValueChanged = { formState.value = formState.value.copy(broadcast_send_as_node = it) },
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Editor for the repeated `broadcast_targets` list (Apple FR-014 multi-target). Each target carries its own
+ * region/preset and an optional `channel_index`; rows can be added and removed. When the list is non-empty it
+ * supersedes the single-target `broadcast_on_*` scalars.
+ */
+@Composable
+private fun BroadcastTargetsCard(
+ targets: List<MeshBeaconConfig.BroadcastTarget>,
+ enabled: Boolean,
+ onChange: (List<MeshBeaconConfig.BroadcastTarget>) -> Unit,
+) {
+ TitledCard(title = stringResource(Res.string.mesh_beacon_targets)) {
+ targets.forEachIndexed { index, target ->
+ if (index > 0) HorizontalDivider()
+ DropDownPreference(
+ title = stringResource(Res.string.mesh_beacon_on_region),
+ selectedItem = target.region,
+ enabled = enabled,
+ onItemSelected = { sel ->
+ onChange(targets.mapIndexed { i, t -> if (i == index) t.copy(region = sel) else t })
+ },
+ )
+ DropDownPreference(
+ title = stringResource(Res.string.mesh_beacon_on_preset),
+ selectedItem = target.preset ?: ModemPreset.LONG_FAST,
+ enabled = enabled,
+ onItemSelected = { sel ->
+ onChange(targets.mapIndexed { i, t -> if (i == index) t.copy(preset = sel) else t })
+ },
+ )
+ SignedIntegerEditTextPreference(
+ title = stringResource(Res.string.mesh_beacon_target_channel_index),
+ value = target.channel_index ?: 0,
+ enabled = enabled,
+ keyboardActions = KeyboardActions.Default,
+ onValueChanged = { v ->
+ onChange(targets.mapIndexed { i, t -> if (i == index) t.copy(channel_index = v) else t })
+ },
+ )
+ TextButton(onClick = { onChange(targets.filterIndexed { i, _ -> i != index }) }, enabled = enabled) {
+ Text(stringResource(Res.string.mesh_beacon_target_remove))
+ }
+ }
+ HorizontalDivider()
+ TextButton(onClick = { onChange(targets + MeshBeaconConfig.BroadcastTarget()) }, enabled = enabled) {
+ Text(stringResource(Res.string.mesh_beacon_target_add))
+ }
+ }
+}

Served by rngit 1.5.0 - Generated in 0.37s